상태관리 : 내가 Server State를 사용하는 방법

Server State 와 Client State를 분리한 이유

React 컴포넌트는 도큐먼트에 따르면, 관심사 분리 & 단일 책임 원칙(SRP) 에 따라 하나의 역할만 담당하는 것을 지향한다. UI 상태(Client State)서버 상태(Server State) 를 혼합하면 컴포넌트가 불필요하게 복잡해지고, 관리하기 어려워지는 것은 다들 느껴봤을 것이다.

개인적으로 Redux 시절에는 Store에 상태보다 API 호출 및 관련 코드가 많아 Store가 거대한 API 관리소처럼 변하기도 했던 기억이 있다. 로딩상태 동기화 방식을 편하게 해주면서 서버 상태를 분리해주는 React Query, Apollo Client 같은 도구를 사용하게 되었다.

굳이 왜 React-Query 같은 도구 사용하냐면

useEffect + useState를 사용하면 되지 뭐하러 react-query를 사용할까?
아래와 같은 코드로 시작을 하게될텐데, 여기에는 몇가지 문제가 있다.

function Bookmarks() {
  const [category, setCategory] = useState('hello');
  const [data, setData] = useState([]);
  const [error, setError] = useState();

  useEffect(() => {
    fetch(`${endpoint}/${category}`)
      .then((res) => res.json())
      .then((d) => setData(d))
      .catch((e) => setError(e));
  }, [category]);

  // Return JSX based on data and error state
}
  1. Race condition
    • category가 변경되어, effect가 다시 실행될 때 레이스 컨디션이 발생할 수 있다. 카테고리를 유저가 계속 바꿨을때, 먼저 간 네트워크 요청이 먼저 온다는 보장이 없기 때문에, 다른 카테고리의 데이터를 볼 수도 있다.
    • React-Query는 queryKey 기반으로 응답을 매칭해주기 때문에 그런 일이 발생하지 않는다.
  2. 필연적인 서버 상태
    • 서버 상태는 필연적으로 로딩상태, 에러상태에 대한 처리나, 인자가 변경되었을 때 초기화 처리도 해줘야한다.
    • 이것을 React-Query가 쉽게 해준다.
  3. Strict Mode에서 두 번 호출된다.
  4. React-Query는 캐시처리 등을 해준다.

이렇듯 데이터페칭은 어렵지 않지만, 비동기 상태 관리가 어렵다. 이런 문제를 리액트 쿼리가 훅 호출 한번으로 해결해주니, 충분히 사용할만한 가치가 있는 것이다. 리액트 쿼리를 사용하면 위 문제를 다 해결해준다.


React-Query

SWR (stale-while-revalidation)

React Query의 주요 캐싱 전략은 'stale-while-revalidate'이며, 이 전략은 기본적으로 활성 쿼리(active query)가 존재하면 캐시된 데이터를 즉시 제공하고, 백그라운드에서 새로운 데이터를 다시 가져와 UI를 업데이트하는 방식입니다. 또한 staleTime과 gcTime (garbage collection time)을 설정하여 데이터의 신선도와 유효 기간을 제어할 수 있습니다.

관련 용어

  • staleTime:데이터가 '신선한(fresh)' 상태로 간주되는 시간을 설정한다. staleTime이 지나지 않은 데이터는 '오래된(stale)' 데이터로 간주되며, 새로운 요청 시 백그라운드 리페칭이 발생한다 기본값은 $0$이며, 이는 데이터가 요청될 때마다 즉시 백그라운드 리페칭이 발생한다는 의미입니다.
  • gcTime: 가비지 컬렉션(gc) 시간을 설정하여, 쿼리 인스턴스가 비활성화된(in-active) 후 캐시된 데이터가 메모리에서 제거되기까지의 시간을 제어합니다. 기본값은 $5$분입니다.=
  • Query Key: queryKey는 데이터를 식별하고 캐시를 관리하는 중요한 요소입니다. 같은 queryKey를 가진 쿼리는 동일한 캐시 공간을 공유합니다. 데이터를 명확하게 구분하기 위해 쿼리 키를 신중하게 설계해야 합니다

그렇다면 다른 캐싱 전략은 뭐가 있을까 ?

  1. Cache-First (캐시 우선)
    • 핵심: 캐시에 데이터가 있으면 캐시를 바로 사용하고, 없을 때만 서버 요청
    • 장점: 빠른 응답, 서버 요청 최소화
    • 단점: 데이터가 오래되면 최신 정보 반영 어려움
    • React Query 대응: staleTime을 충분히 길게 설정하고, enabled: false로 수동 refetch
  2. Network-First (네트워크 우선)
    • 핵심: 서버에서 항상 최신 데이터를 먼저 요청하고, 실패 시 캐시 사용
    • 장점: 항상 최신 데이터 보장
    • 단점: 네트워크 지연 발생 시 UX 저하
    • React Query 대응: staleTime = 0, 즉시 refetch
  3. Cache-and-Network (동시 접근)
    • 핵심: 캐시 데이터를 즉시 보여주면서 서버에도 요청=
    • 장점: SWR과 유사하지만, 서버 요청이 UI 갱신에 강하게 연결됨
    • 단점: 네트워크 요청이 많아질 수 있음
    • React Query 대응: refetchOnMount: true, refetchOnWindowFocus: true
  4. Network-Only (서버만)
    • 핵심: 항상 서버에서 가져오고 캐시는 사용하지 않음
    • 장점: 최신 데이터 보장, 단순
    • 단점: UX 느림, 오프라인 대응 불가

React Query를 사용하며 만난 이슈들

  1. queryKey 관리 복잡성

    규모가 커지면 queryKey가 복잡해진다. 이를 위해 queryKey factory 패턴을 활용해 namespace를 관리하는 방식으로 개선했다.

  2. useSuspenseQuery의 throw

    Suspense 기반 쿼리는 내부적으로 throw를 사용하기 때문에, 중첩된 구조에서는 하위 컴포넌트의 API 호출이 블록되는 경우가 있다.

  3. GraphQL과 React Query의 갭

    GraphQL을 사용할 때는 React Query의 캐싱 모델이 REST처럼 느껴질 때가 있다. 정규화된 캐싱을 원하면 Apollo Client가 더 자연스럽다.

  4. useInfiniteQuery vs useQuery 충돌

    두 훅을 동일한 queryKey로 사용하면 서로의 캐시를 덮어씌운다. 데이터 구조 자체가 다르기 때문에 같은 queryKey를 사용하면 안 된다.

  5. enabled 옵션의 타입 안정성

    enabled는 훌륭하지만 undefined 타입 인자를 처리할 때 번거로움이 있다. React Query v5.25 이후부터 skipToken을 사용하면서 훨씬 우아하게 해결된다.

    import { skipToken, useQuery } from '@tanstack/query';
    
    const useGroup = (id: number | undefined) => {
      return useQuery({
        queryKey: ['group', id],
        queryFn: id ? () => fetchGroup(id) : skipToken,
      });
    };
    

Apollo Client

GraphQL 전용 클라이언트로, 서버 상태 관리 도구 중 가장 강력한 캐싱 시스템을 갖추고 있다. 쿼리, 뮤테이션, 서브스크립션을 모두 지원하며, 서버 응답을 정규화하여 캐시를 관리한다.

Apollo의 정규화 캐싱

서버에서 받은 데이터를 그대로 캐싱하는 것이 아니라, typename + id 형태로 분해하여 엔티티 단위로 저장한다. mutation 이후 변경된 id의 데이터를 캐시에 반영하면, 그 id를 참조 중인 모든 쿼리가 자동으로 최신 상태를 반영한다.

React Query의 queryKey 기반 캐싱과는 완전히 다른 철학이다. GraphQL 구조와 가장 잘 맞는 방식이며, 대규모 데이터 구조에서도 강력한 정합성을 제공한다.

관련 용어

  • cacheTime : 캐시 데이터의 메모리 유지 시간. 5분이 기본값이며, 접근이 없으면 해당 시간이 지나고 삭제된다.
  • FetchPolicy :Apollo는 staleTime 대신 fetchPolicy로 캐싱 전략을 정의한다.
    • cache-first : 캐시가 있으면 바로 사용. 서버 요청 최소화.
    • network-only (no-store) : 매번 서버에서 요청. 최신성이 중요할 때.
    • cache-and-network : 캐시를 보여주고 동시에 서버 요청. React Query의 SWR과 유사. SWR과 다른 점은 항상 백그라운드에서 캐시를 갱신한다는 점이다.
    • no-cache : 캐시를 저장하지 않는 것이 아니라, “항상 서버 검증 필요”라는 의미. ETag 또는 Last-Modified 기반 조건부 요청이 수행된다.
    • nextFetchPolicy : 캐시 이후 다음 fetch가 어떤 정책을 따를지 정의한다.

GraphQL 타입 관리

GraphQL을 쓸 때 가장 불편한 부분 중 하나가 Fragment 타입 관리다. 스키마가 바뀔 때마다 수동으로 타입을 갱신하는 것은 실수하기 쉽다. 이 때 apollo-codegen 같은 도구를 사용하면 schema → TypeScript 타입을 자동 생성해주기 때문에, 생산성이 눈에 띄게 좋아진다.